package io.kiki.stack.http.feign.SpringMvcContract;

import io.kiki.stack.http.feign.*;
import io.kiki.stack.http.feign.SpringMvcContract.clientconfig.FeignClientConfigurer;
import io.kiki.stack.http.feign.SpringMvcContract.loadbalancer.FeignBlockingLoadBalancerClient;
import io.kiki.stack.http.feign.SpringMvcContract.loadbalancer.RetryableFeignBlockingLoadBalancerClient;
import io.kiki.stack.http.feign.Target.HardCodedTarget;
import io.kiki.stack.http.feign.codec.Decoder;
import io.kiki.stack.http.feign.codec.Encoder;
import io.kiki.stack.http.feign.codec.ErrorDecoder;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.*;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import java.util.*;
import java.util.concurrent.TimeUnit;

public class FeignClientFactoryBean implements FactoryBean<Object>, InitializingBean, ApplicationContextAware, BeanFactoryAware {

    /***********************************
     * WARNING! Nothing in this class should be @Autowired. It causes NPEs because of some
     * lifecycle race condition.
     ***********************************/

    private static final Log LOG = LogFactory.getLog(FeignClientFactoryBean.class);
    private final List<FeignBuilderCustomizer> additionalCustomizers = new ArrayList<>();
    @Setter
    @Getter
    private Class<?> type;
    @Setter
    @Getter
    private String name;
    @Setter
    @Getter
    private String url;
    @Setter
    @Getter
    private String path;

    @Setter
    @Getter
    private boolean inheritParentContext = true;
    @Getter
    private ApplicationContext applicationContext;
    private BeanFactory beanFactory;

    private int readTimeoutMillis = new Request.Options().readTimeoutMillis();
    private int connectTimeoutMillis = new Request.Options().connectTimeoutMillis();
    private boolean followRedirects = new Request.Options().isFollowRedirects();
    @Setter
    private boolean refreshableClient = false;


    // For AOT testing
    public FeignClientFactoryBean() {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Creating a FeignClientFactoryBean.");
        }
    }

    @Override
    public void afterPropertiesSet() {
        Assert.hasText(null, "Context id must be set");
        Assert.hasText(name, "Name must be set");
    }

    protected Feign.Builder feign(FeignClientFactory context) {
        FeignLoggerFactory loggerFactory = get(context, FeignLoggerFactory.class);
        Logger logger = loggerFactory.create(type);

        // @formatter:off
		Feign.Builder builder = get(context, Feign.Builder.class)
				// required values
				.logger(logger)
				.encoder(get(context, Encoder.class))
				.decoder(get(context, Decoder.class))
				.contract(get(context, Contract.class));
		// @formatter:on

        configureFeign(context, builder);

        return builder;
    }

    private void applyBuildCustomizers(FeignClientFactory context, Feign.Builder builder) {
        Map<String, FeignBuilderCustomizer> customizerMap = context.getInstances(null, FeignBuilderCustomizer.class);

        if (customizerMap != null) {
            customizerMap.values().stream().sorted(AnnotationAwareOrderComparator.INSTANCE).forEach(feignBuilderCustomizer -> feignBuilderCustomizer.customize(builder));
        }
        additionalCustomizers.forEach(customizer -> customizer.customize(builder));
    }

    protected void configureFeign(FeignClientFactory context, Feign.Builder builder) {
        FeignClientProperties properties = beanFactory != null ? beanFactory.getBean(FeignClientProperties.class) : applicationContext.getBean(FeignClientProperties.class);

        FeignClientConfigurer feignClientConfigurer = getOptional(context, FeignClientConfigurer.class);
        setInheritParentContext(feignClientConfigurer.inheritParentConfiguration());

        if (properties != null && inheritParentContext) {
            if (properties.isDefaultToProperties()) {
                configureUsingConfiguration(context, builder);
                configureUsingProperties(properties.getConfig().get(properties.getDefaultConfig()), properties.getConfig().get(null), builder);
            } else {
                configureUsingProperties(properties.getConfig().get(properties.getDefaultConfig()), properties.getConfig().get(null), builder);
                configureUsingConfiguration(context, builder);
            }
            configureDefaultRequestElements(properties.getConfig().get(properties.getDefaultConfig()), properties.getConfig().get(null), builder);
        } else {
            configureUsingConfiguration(context, builder);
        }
    }

    protected void configureUsingConfiguration(FeignClientFactory context, Feign.Builder builder) {
        Logger.Level level = getInheritedAwareOptional(context, Logger.Level.class);
        if (level != null) {
            builder.logLevel(level);
        }
        Retryer retryer = getInheritedAwareOptional(context, Retryer.class);
        if (retryer != null) {
            builder.retryer(retryer);
        }
        ErrorDecoder errorDecoder = getInheritedAwareOptional(context, ErrorDecoder.class);
        if (errorDecoder != null) {
            builder.errorDecoder(errorDecoder);
        } else {
            FeignErrorDecoderFactory errorDecoderFactory = getOptional(context, FeignErrorDecoderFactory.class);
            if (errorDecoderFactory != null) {
                ErrorDecoder factoryErrorDecoder = errorDecoderFactory.create(type);
                builder.errorDecoder(factoryErrorDecoder);
            }
        }
        Request.Options options = getInheritedAwareOptional(context, Request.Options.class);
        if (options == null) {
            options = getOptionsByName(context, null);
        }

        if (options != null) {
            builder.options(options);
            readTimeoutMillis = options.readTimeoutMillis();
            connectTimeoutMillis = options.connectTimeoutMillis();
            followRedirects = options.isFollowRedirects();
        }
        Map<String, RequestInterceptor> requestInterceptors = getInheritedAwareInstances(context, RequestInterceptor.class);
        if (requestInterceptors != null) {
            List<RequestInterceptor> interceptors = new ArrayList<>(requestInterceptors.values());
            AnnotationAwareOrderComparator.sort(interceptors);
            builder.requestInterceptors(interceptors);
        }
        ResponseInterceptor responseInterceptor = getInheritedAwareOptional(context, ResponseInterceptor.class);
        if (responseInterceptor != null) {
            builder.responseInterceptor(responseInterceptor);
        }
        QueryMapEncoder queryMapEncoder = getInheritedAwareOptional(context, QueryMapEncoder.class);
        if (queryMapEncoder != null) {
            builder.queryMapEncoder(queryMapEncoder);
        }

        ExceptionPropagationPolicy exceptionPropagationPolicy = getInheritedAwareOptional(context, ExceptionPropagationPolicy.class);
        if (exceptionPropagationPolicy != null) {
            builder.exceptionPropagationPolicy(exceptionPropagationPolicy);
        }

        Map<String, Capability> capabilities = getInheritedAwareInstances(context, Capability.class);
        if (capabilities != null) {
            capabilities.values().stream().sorted(AnnotationAwareOrderComparator.INSTANCE).forEach(builder::addCapability);
        }
    }

    protected void configureUsingProperties(FeignClientProperties.FeignClientConfig baseConfig, FeignClientProperties.FeignClientConfig finalConfig, Feign.Builder builder) {
        configureUsingProperties(baseConfig, builder);
        configureUsingProperties(finalConfig, builder);

    }

    protected void configureUsingProperties(FeignClientProperties.FeignClientConfig config, Feign.Builder builder) {
        if (config == null) {
            return;
        }

        if (config.getLoggerLevel() != null) {
            builder.logLevel(config.getLoggerLevel());
        }

        if (!refreshableClient) {
            connectTimeoutMillis = config.getConnectTimeout() != null ? config.getConnectTimeout() : connectTimeoutMillis;
            readTimeoutMillis = config.getReadTimeout() != null ? config.getReadTimeout() : readTimeoutMillis;
            followRedirects = config.isFollowRedirects() != null ? config.isFollowRedirects() : followRedirects;

            builder.options(new Request.Options(connectTimeoutMillis, TimeUnit.MILLISECONDS, readTimeoutMillis, TimeUnit.MILLISECONDS, followRedirects));
        }

        if (config.getRetryer() != null) {
            Retryer retryer = getOrInstantiate(config.getRetryer());
            builder.retryer(retryer);
        }

        if (config.getErrorDecoder() != null) {
            ErrorDecoder errorDecoder = getOrInstantiate(config.getErrorDecoder());
            builder.errorDecoder(errorDecoder);
        }

        if (config.getRequestInterceptors() != null && !config.getRequestInterceptors().isEmpty()) {
            // this will add request interceptor to builder, not replace existing
            for (Class<RequestInterceptor> bean : config.getRequestInterceptors()) {
                RequestInterceptor interceptor = getOrInstantiate(bean);
                builder.requestInterceptor(interceptor);
            }
        }

        if (config.getResponseInterceptor() != null) {
            builder.responseInterceptor(getOrInstantiate(config.getResponseInterceptor()));
        }

        if (Objects.nonNull(config.getEncoder())) {
            builder.encoder(getOrInstantiate(config.getEncoder()));
        }

        if (Objects.nonNull(config.getDecoder())) {
            builder.decoder(getOrInstantiate(config.getDecoder()));
        }

        if (Objects.nonNull(config.getContract())) {
            builder.contract(getOrInstantiate(config.getContract()));
        }

        if (Objects.nonNull(config.getExceptionPropagationPolicy())) {
            builder.exceptionPropagationPolicy(config.getExceptionPropagationPolicy());
        }

        if (config.getCapabilities() != null) {
            config.getCapabilities().stream().map(this::getOrInstantiate).forEach(builder::addCapability);
        }

        if (config.getQueryMapEncoder() != null) {
            builder.queryMapEncoder(getOrInstantiate(config.getQueryMapEncoder()));
        }
    }

    protected void configureDefaultRequestElements(FeignClientProperties.FeignClientConfig defaultConfig, FeignClientProperties.FeignClientConfig clientConfig, Feign.Builder builder) {
        Map<String, Collection<String>> defaultRequestHeaders = new HashMap<>();
        if (defaultConfig != null) {
            defaultConfig.getDefaultRequestHeaders().forEach((k, v) -> defaultRequestHeaders.put(k, new ArrayList<>(v)));
        }
        if (clientConfig != null) {
            clientConfig.getDefaultRequestHeaders().forEach((k, v) -> defaultRequestHeaders.put(k, new ArrayList<>(v)));
        }
        if (!defaultRequestHeaders.isEmpty()) {
            addDefaultRequestHeaders(defaultRequestHeaders, builder);
        }

        Map<String, Collection<String>> defaultQueryParameters = new HashMap<>();
        if (defaultConfig != null) {
            defaultConfig.getDefaultQueryParameters().forEach((k, v) -> defaultQueryParameters.put(k, new ArrayList<>(v)));
        }
        if (clientConfig != null) {
            clientConfig.getDefaultQueryParameters().forEach((k, v) -> defaultQueryParameters.put(k, new ArrayList<>(v)));
        }
        if (!defaultQueryParameters.isEmpty()) {
            addDefaultQueryParams(defaultQueryParameters, builder);
        }

    }

    private void addDefaultQueryParams(Map<String, Collection<String>> defaultQueryParameters, Feign.Builder builder) {
        builder.requestInterceptor(requestTemplate -> {
            Map<String, Collection<String>> queries = requestTemplate.queries();
            defaultQueryParameters.keySet().forEach(key -> {
                if (!queries.containsKey(key)) {
                    requestTemplate.query(key, defaultQueryParameters.get(key));
                }
            });
        });
    }

    private void addDefaultRequestHeaders(Map<String, Collection<String>> defaultRequestHeaders, Feign.Builder builder) {
        builder.requestInterceptor(requestTemplate -> {
            Map<String, Collection<String>> headers = requestTemplate.headers();
            defaultRequestHeaders.keySet().forEach(key -> {
                if (!headers.containsKey(key)) {
                    requestTemplate.header(key, defaultRequestHeaders.get(key));
                }
            });
        });
    }

    private <T> T getOrInstantiate(Class<T> tClass) {
        try {
            return beanFactory != null ? beanFactory.getBean(tClass) : applicationContext.getBean(tClass);
        } catch (NoSuchBeanDefinitionException e) {
            return BeanUtils.instantiateClass(tClass);
        }
    }

    protected <T> T get(FeignClientFactory context, Class<T> type) {
        T instance = context.getInstance(null, type);
        if (instance == null) {
            throw new IllegalStateException("No bean found of type " + type + " for " + null);
        }
        return instance;
    }

    protected <T> T getOptional(FeignClientFactory context, Class<T> type) {
        return context.getInstance(null, type);
    }

    protected <T> T getInheritedAwareOptional(FeignClientFactory context, Class<T> type) {
        if (inheritParentContext) {
            return getOptional(context, type);
        } else {
            return context.getInstanceWithoutAncestors(null, type);
        }
    }

    protected <T> Map<String, T> getInheritedAwareInstances(FeignClientFactory context, Class<T> type) {
        if (inheritParentContext) {
            return context.getInstances(null, type);
        } else {
            return context.getInstancesWithoutAncestors(null, type);
        }
    }

    protected <T> T loadBalance(Feign.Builder builder, FeignClientFactory context, HardCodedTarget<T> target) {
        Client client = getOptional(context, Client.class);
        if (client != null) {
            builder.client(client);
            applyBuildCustomizers(context, builder);
            Targeter targeter = get(context, Targeter.class);
            return targeter.target(this, builder, context, target);
        }

        throw new IllegalStateException("No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-loadbalancer?");
    }

    /**
     * Meant to get Options bean from context with bean name.
     *
     * @param context   context of Feign client
     * @param contextId name of feign client
     * @return returns Options found in context
     */
    protected Request.Options getOptionsByName(FeignClientFactory context, String contextId) {
        if (refreshableClient) {
            return context.getInstance(contextId, Request.Options.class.getCanonicalName() + "-" + contextId, Request.Options.class);
        }
        return null;
    }

    @Override
    public Object getObject() {
        return getTarget();
    }

    /**
     * @param <T> the target type of the Feign client
     * @return a {@link Feign} client created with the specified data and the context
     * information
     */
    @SuppressWarnings("unchecked")
    <T> T getTarget() {
        FeignClientFactory feignClientFactory = beanFactory != null ? beanFactory.getBean(FeignClientFactory.class) : applicationContext.getBean(FeignClientFactory.class);
        Feign.Builder builder = feign(feignClientFactory);
        if (!StringUtils.hasText(url) && !isUrlAvailableInConfig(null)) {

            if (LOG.isInfoEnabled()) {
                LOG.info("For '" + name + "' URL not provided. Will try picking an instance via load-balancing.");
            }
            if (!name.startsWith("http://") && !name.startsWith("https://")) {
                url = "http://" + name;
            } else {
                url = name;
            }
            url += cleanPath();
            return (T) loadBalance(builder, feignClientFactory, new HardCodedTarget<>(type, name, url));
        }
        if (StringUtils.hasText(url) && !url.startsWith("http://") && !url.startsWith("https://")) {
            url = "http://" + url;
        }
        Client client = getOptional(feignClientFactory, Client.class);
        if (client != null) {
            if (client instanceof FeignBlockingLoadBalancerClient) {
                // not load balancing because we have a url,
                // but Spring Cloud LoadBalancer is on the classpath, so unwrap
                client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
            }
            if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
                // not load balancing because we have a url,
                // but Spring Cloud LoadBalancer is on the classpath, so unwrap
                client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
            }
            builder.client(client);
        }

        applyBuildCustomizers(feignClientFactory, builder);

        Targeter targeter = get(feignClientFactory, Targeter.class);
        return targeter.target(this, builder, feignClientFactory, resolveTarget(feignClientFactory, null, url));
    }

    private String cleanPath() {
        if (path == null) {
            return "";
        }
        String path = this.path.trim();
        if (StringUtils.hasLength(path)) {
            if (!path.startsWith("/")) {
                path = "/" + path;
            }
            if (path.endsWith("/")) {
                path = path.substring(0, path.length() - 1);
            }
        }
        return path;
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    private <T> HardCodedTarget<T> resolveTarget(FeignClientFactory context, String contextId, String url) {
        if (StringUtils.hasText(url)) {
            return new HardCodedTarget(type, name, url + cleanPath());
        }

        if (refreshableClient) {
            RefreshableUrl refreshableUrl = context.getInstance(contextId, RefreshableUrl.class.getCanonicalName() + "-" + contextId, RefreshableUrl.class);
            if (Objects.nonNull(refreshableUrl) && StringUtils.hasText(refreshableUrl.getUrl())) {
                return new RefreshableHardCodedTarget<>(type, name, refreshableUrl, cleanPath());
            }
        }
        FeignClientProperties.FeignClientConfig config = findConfigByKey(contextId);
        if (Objects.isNull(config) || !StringUtils.hasText(config.getUrl())) {
            throw new IllegalStateException("Provide Feign client URL either in @FeignClient() or in config properties.");
        }

        return new PropertyBasedTarget(type, name, config, cleanPath());
    }

    private boolean isUrlAvailableInConfig(String contextId) {
        FeignClientProperties.FeignClientConfig config = findConfigByKey(contextId);
        return Objects.nonNull(config) && StringUtils.hasText(config.getUrl());
    }

    private FeignClientProperties.FeignClientConfig findConfigByKey(String configKey) {
        FeignClientProperties properties = beanFactory != null ? beanFactory.getBean(FeignClientProperties.class) : applicationContext.getBean(FeignClientProperties.class);
        return properties.getConfig().get(configKey);
    }

    @Override
    public Class<?> getObjectType() {
        return type;
    }

    @Override
    public boolean isSingleton() {
        return true;
    }

    public void addCustomizer(FeignBuilderCustomizer customizer) {
        additionalCustomizers.add(customizer);
    }

    @Override
    public void setApplicationContext(ApplicationContext context) throws BeansException {
        applicationContext = context;
        beanFactory = context;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        FeignClientFactoryBean that = (FeignClientFactoryBean) o;
        return Objects.equals(applicationContext, that.applicationContext) && Objects.equals(beanFactory, that.beanFactory) && inheritParentContext == that.inheritParentContext && Objects.equals(name, that.name) && Objects.equals(path, that.path) && Objects.equals(type, that.type) && Objects.equals(url, that.url) && Objects.equals(connectTimeoutMillis, that.connectTimeoutMillis) && Objects.equals(readTimeoutMillis, that.readTimeoutMillis) && Objects.equals(followRedirects, that.followRedirects) && Objects.equals(refreshableClient, that.refreshableClient);
    }

    @Override
    public int hashCode() {
        return Objects.hash(applicationContext, beanFactory, inheritParentContext, name, path, type, url, readTimeoutMillis, connectTimeoutMillis, followRedirects, refreshableClient);
    }

    @Override
    public String toString() {
        return "FeignClientFactoryBean{" + "type=" + type + ", " + "name='" + name + "', " + "url='" + url + "', " + "path='" + path + "', " + ", " + "inheritParentContext=" + inheritParentContext + ", " + "applicationContext=" + applicationContext + ", " + "beanFactory=" + beanFactory + "}" + "connectTimeoutMillis=" + connectTimeoutMillis + "}" + "readTimeoutMillis=" + readTimeoutMillis + "}" + "followRedirects=" + followRedirects + "refreshableClient=" + refreshableClient + "}";
    }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = beanFactory;
    }

}
